Repository navigation
Add liquid glass bottom tab bar to iOS, Android and mWeb - #101339
sumo-slonik wants to merge 76 commits into
Conversation
Single floating capsule, native glass on iOS 26, translucent fallback elsewhere.
UITabBar gives iOS 26 glass, Material bar on Android, rounded JS bar on web.
bottom-tabs 7.16.2, native 7.2.6, elements 2.9.19. rns 4.25.0 needs screenKey.
An interactive swipe back left the bar out until the gesture ended.
Adds an RNScreens patch tinting unselected items on iOS 26.
|
|
# Conflicts: # src/components/Navigation/NavigationTabBar/index.tsx # src/pages/Insights/InsightsPage.tsx # src/pages/home/HomePage.tsx # src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx
Untint icons, label every tab, move visibility to screen options, tear down the color scheme coordinator.
The tab fragment is removed and never re-added on the pop. Also reset the color scheme coordinator.
The page skeleton stops at the first cached data and the list rendered a zero-sized view in the gap.
Android leaves the Material bar for the JS one; iOS paints its labels into the icon bitmaps.
# Conflicts: # src/pages/Insights/InsightsPage.tsx # src/pages/inbox/sidebar/SidebarLinks.tsx
Restore Android tab state, gate Insights on registered tabs, sum LHN inset.
Icons go to 20x20 with a tighter label gap; Android and mWeb blur the backdrop.
Adds a milky glass tint over a light blur.
… web Drops the blur layer and expo-blur, and removes dead reveal and lazy-split code.
|
@MelvinBot review |
|
@codex review |
Code reviewI found 2 likely bugs, 3 medium issues and a few smaller ones. The dependency bumps and patches look correct. I reviewed the code at Likely bugs
Medium
Smaller issues
Checked and fine
|
JmillsExpensify
left a comment
There was a problem hiding this comment.
Just tested the latest adhoc. This is looking super great. Product approved.
Great, it's really nice to hear that! |
|
Assigned @ZhenjaHorbach for the C+ review. He'll start it soon. |
| "@react-navigation/bottom-tabs": "7.16.2", | ||
| "@react-navigation/core": "7.21.12", | ||
| "@react-navigation/material-top-tabs": "7.4.19", | ||
| "@react-navigation/native": "7.1.33", | ||
| "@react-navigation/native": "7.2.6", |
There was a problem hiding this comment.
How about using the newest versions of those two? We could also extract this into a separate PR, which proved to be the best approach for such complex and important changes. This way we can avoid reverts if there are any issues with the new versions
There was a problem hiding this comment.
Good idea, I extracted it into a separate PR: #103346. It bumps both to the newest versions that don't require a @react-navigation/core bump.
| const floatingTabBarHeight = 60; | ||
| const floatingTabBarBottomInset = 8; |
There was a problem hiding this comment.
Why are those two defined outside the export? 🤔
There was a problem hiding this comment.
Good catch, already fixed.
|
|
||
| type NativeTabLayoutProps = Parameters<NonNullable<NativeBottomTabNavigatorProps['layout']>>[0]; | ||
|
|
||
| function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { |
| <View style={styles.flex1}> | ||
| {children} | ||
| <TabPressListeners | ||
| state={state} | ||
| descriptors={descriptors} | ||
| /> | ||
| {!!isDebugModeEnabled && shouldShowNativeTabBar && <DebugTabView selectedTab={selectedTab} />} | ||
| {shouldShowNativeTabBar && ( | ||
| // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. | ||
| // They leave faster than they come in, so they stop covering a bar that is still sliding out. |
There was a problem hiding this comment.
We could extract this entire tree to a separate component so wide layout don't pay for unnecessary hooks and onyx subscriptions
There was a problem hiding this comment.
Good point, I already extracted it into NativeTabBarOverlay.
| state={state} | ||
| descriptors={descriptors} | ||
| /> | ||
| {!!isDebugModeEnabled && shouldShowNativeTabBar && <DebugTabView selectedTab={selectedTab} />} |
There was a problem hiding this comment.
I think we can handle debug mode as a follow-up. What do you think?
There was a problem hiding this comment.
It's more like a design question, so summoning @Expensify/design
There was a problem hiding this comment.
Down to do debug stuff as a follow up. We can make that look a little nicer for sure, but I wouldn't block on it since it's not user facing.
| descriptors={descriptors} | ||
| /> | ||
| {!!isDebugModeEnabled && shouldShowNativeTabBar && <DebugTabView selectedTab={selectedTab} />} | ||
| {shouldShowNativeTabBar && ( |
There was a problem hiding this comment.
This branch won't fire when we should use the narrow layout, and we shouldnt show native tab bar. So what exactly should happen in this scenario?
There was a problem hiding this comment.
So this is basically Android file, right? Maybe we could rename it and make the index.ts no-op?
There was a problem hiding this comment.
Good idea, I already renamed it to index.android.ts.
| * The native bar shows at most five of the six tabs. With the Insights beta, Insights takes the Account tab's place | ||
| * and Account moves to the top bar; without it, Insights has no item. | ||
| */ | ||
| function getTabWithoutBarItem(isInsightsBetaEnabled: boolean) { |
There was a problem hiding this comment.
Do I understand that the account tab (with the beta on) is still a tab, even though it lives outside the bottom tab navigator? Why is that? Can't it be just a regular screen?
| const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { | ||
| weight: isBold ? FontWeight.Bold : FontWeight.Normal, | ||
| }); | ||
| const font = Skia.Font(typeface, size); |
There was a problem hiding this comment.
Let's make sure that all languages are supported. I think Chinese could be affected and dont' display anything
| - Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for. | ||
| - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. `updateFontStyles` sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. Material's `setItemTextAppearanceActiveBoldEnabled` is not used for this: it re-applies Material's 12sp text size to the large label alone and recomputes the selected item's offset from the difference between the two labels' sizes, so every selected tab would sit higher than the others. | ||
| - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets the letter spacing of both labels to 0. | ||
| - Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either. |
There was a problem hiding this comment.
Maybe we could add such options in an upstream PR? If not we'll have to support this patch forever
| ### [react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+003+no-android-tab-icon-tint.patch) | ||
|
|
||
| - Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`. | ||
| - Upstream PR/issue: not reported, because dropping the tint for every icon fits only a bar whose icons all arrive pre-colored, as the App's do. The disabled and focused icon colors go with it, which the App does not use. |
There was a problem hiding this comment.
Same here, can't we add an option in react-native-screens?
| const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); | ||
| const navigation = useNavigation(); | ||
| const isDrawnOverTabs = useIsSettingsDrawnOverTabs(); | ||
| // The tab navigator keeps the full tab history, so going back returns to the tab the user opened Account from. |
There was a problem hiding this comment.
Minor comment
But let's add empty lines between blocks
| <Tab.Screen | ||
| name={NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR} |
There was a problem hiding this comment.
Do we need these changes?
| useNativeTabNavigator(); | ||
| const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); | ||
| // A tab with no bar item draws no icon. | ||
| const getOptions = (name: NativeTabName) => |
There was a problem hiding this comment.
I think we don't need to pass name ourselves into every Tab.Screen
Maybe we can update getOptions to
const getOptions = ({route}: {route: {name: NativeTabName}}) => {
const name = route.name;
if (name === tabWithoutBarItem) {
return HIDDEN_TAB_OPTIONS;
}
return {
...getTabOptions(name),
tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}),
};
};
As result we will have <Tab.Navigator screenOptions={getOptions}>
What do you think?
…he tab width, tabs keep their state across the breakpoint, wide Spend opens last search
…reenOptions, Android options file, offline indicator above the mWeb bar
…ithout core 7.21.13 typing getRootState as possibly undefined
dylanexpensify
left a comment
There was a problem hiding this comment.
Looks good from a product perspective 👍
|
🚧 JmillsExpensify has triggered a test Expensify/App build. You can view the workflow run here. |
… root, measured from the screen bottom
c1aabf4
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
|
@sumo-slonik |





Explanation of Change
Replaces the JS bottom tab bar on narrow layouts, changing its look on every platform:
UITabBarController, rendered with liquid glass on iOS 26.The native bars come from
createNativeBottomTabNavigator(@react-navigation/bottom-tabs/unstable) on top ofTabsfromreact-native-screens. Both are experimental APIs, so upgrading either package may break this.iOS 26 ignores most tab item colors and paints every badge in the selected tab's color, so icons are drawn with Skia, each with its own status dot (on iOS also its label). Wide layouts keep the JS side bar.
Native bars fit 5 of our 6 tabs: with the Insights beta, Account moves to the top bar and opens full screen with a back button; without it, Insights has no item. A native tab tap follows the JS buttons' rules: Inbox opens at its chat list and anonymous users get sign-in.
Native tabs are mounted up front and not frozen, so a tab shows its skeleton as soon as it's picked, with no blank frame.
Patches (details in each
details.md):react-native-screens+4.28.0+001+animate-hiding-the-native-tab-bar: animates hiding and showing the iOS bar instead of a blink. The floating buttons fade with it.react-native-screens+4.28.0+002+android-tab-label-typography: bold selected Android label, no Material letter spacing.react-native-screens+4.28.0+003+no-android-tab-icon-tint: no icon tint on Android, so the avatar keeps its colors.@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence: fixes a bug that ignoredtabBarActiveIndicatorColoron Android.@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items: addstabBarItemHiddenfor the sixth tab, and forwardstabBarAccessibilityLabelfor VoiceOver.@react-navigation/bottom-tabsis bumped to 7.16.2 and@react-navigation/nativeto 7.2.6, with their existing patches carried over.Performance
Tab switching on iOS vs
main(iPhone 17 simulator, iOS 27, dev build, 5 rounds). Average of median changes across all source tabs:Android tab switching is also noticeably smoother with the native Material bar.
Fixed Issues
$ #101169
PROPOSAL: N/A
Tests
Offline tests
Same as the
Testssection with the network turned off. None of these changes depend on network state.QA Steps
// TODO: These must be filled out, or the issue title must include "[No QA]."
Same as the
Testssection.PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionWaiting for copylabel to the issue)Avatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Screen.Recording.2026-10-07.at.08.08.36.mov
Android: mWeb Chrome
iOS: Native
Screen.Recording.2026-10-06.at.22.39.56.mov
iOS: mWeb Safari
MacOS: Chrome / Safari